package com.masterlau.entry;

import org.apache.commons.cli.CommandLine;
import org.apache.commons.cli.CommandLineParser;
import org.apache.commons.cli.DefaultParser;
import org.apache.commons.cli.HelpFormatter;
import org.apache.commons.cli.MissingOptionException;
import org.apache.commons.cli.Option;
import org.apache.commons.cli.OptionGroup;
import org.apache.commons.cli.Options;
import org.apache.commons.cli.ParseException;
import org.apache.commons.io.IOCase;
import org.apache.commons.io.file.DirectoryStreamFilter;
import org.apache.commons.io.filefilter.FileFileFilter;
import org.apache.commons.io.filefilter.SuffixFileFilter;
import org.apache.commons.io.function.IOBiConsumer;
import org.apache.commons.io.function.IOConsumer;
import org.apache.commons.io.function.IORunnable;
import org.apache.maven.shared.invoker.DefaultInvocationRequest;
import org.apache.maven.shared.invoker.DefaultInvoker;
import org.apache.maven.shared.invoker.InvocationRequest;
import org.apache.maven.shared.invoker.InvocationResult;
import org.apache.maven.shared.invoker.Invoker;
import org.apache.maven.shared.invoker.MavenInvocationException;
import org.apache.maven.shared.utils.StringUtils;
import org.apache.sshd.client.SshClient;
import org.apache.sshd.client.channel.ChannelExec;
import org.apache.sshd.client.channel.ClientChannelEvent;
import org.apache.sshd.client.session.ClientSession;
import org.apache.sshd.common.util.io.input.NullInputStream;
import org.apache.sshd.common.util.io.output.LineLevelAppender;
import org.apache.sshd.common.util.io.output.LineLevelAppenderStream;
import org.apache.sshd.sftp.client.SftpClientFactory;
import org.apache.sshd.sftp.client.fs.SftpFileSystem;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static com.masterlau.entry.Helper.DEF_HOST_IP;
import static com.masterlau.entry.Helper.DEF_LOG_EXTENSIONS;
import static com.masterlau.entry.Helper.DEF_MAVEN_BUILD_GOALS;
import static com.masterlau.entry.Helper.DEF_MAVEN_PROFILES;
import static com.masterlau.entry.Helper.DEF_NUM_OF_MODULE;
import static com.masterlau.entry.Helper.DEF_NUM_OF_SERVER;
import static com.masterlau.entry.Helper.DEF_ORDER;
import static com.masterlau.entry.Helper.DEF_TEMPLATE_FILE_NAME;
import static com.masterlau.entry.Helper.DEF_TIMEOUT;
import static com.masterlau.entry.Helper.EMPTY;
import static com.masterlau.entry.Helper.EMPTY_COMPLETABLE_FUTURE_ARRAY;
import static com.masterlau.entry.Helper.EXECUTABLE_FILE_EXTENSION;
import static com.masterlau.entry.Helper.JAVA_HOME_NAME;
import static com.masterlau.entry.Helper.JDK_JAVA_OPTIONS_KEY;
import static com.masterlau.entry.Helper.JDK_JAVA_OPTIONS_VALUE;
import static com.masterlau.entry.Helper.JRE_HOME_NAME;
import static com.masterlau.entry.Helper.M2_HOME_NAME;
import static com.masterlau.entry.Helper.MANUAL_CONFIG_HINT;
import static com.masterlau.entry.Helper.MAVEN_HOME_NAME;
import static com.masterlau.entry.Helper.MAX_NUM_OF_MODULE;
import static com.masterlau.entry.Helper.MAX_NUM_OF_SERVER;
import static com.masterlau.entry.Helper.MVN_HOME_NAME;
import static com.masterlau.entry.Helper.NOT_FOUND_HINT;
import static com.masterlau.entry.Helper.OBJECT_MAPPER;
import static com.masterlau.entry.Helper.OPEN_OPTIONS;
import static com.masterlau.entry.Helper.OPTION_B;
import static com.masterlau.entry.Helper.OPTION_C;
import static com.masterlau.entry.Helper.OPTION_D;
import static com.masterlau.entry.Helper.OPTION_G;
import static com.masterlau.entry.Helper.OPTION_GM;
import static com.masterlau.entry.Helper.OPTION_GS;
import static com.masterlau.entry.Helper.OPTION_L;
import static com.masterlau.entry.Helper.OPTION_M;
import static com.masterlau.entry.Helper.OPTION_P;
import static com.masterlau.entry.Helper.OPTION_R;
import static com.masterlau.entry.Helper.OPTION_S;
import static com.masterlau.entry.Helper.SCRIPT_FILE_EXTENSION;

/**
 * @author MasterLau
 */
public class App {
    private static final Logger LOGGER = LoggerFactory.getLogger(App.class);

    public static void main(String[] args) {
        System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", Integer.toString(Byte.SIZE));
        OptionGroup configGroup = new OptionGroup();
        OptionGroup operationGroup = new OptionGroup();
        configGroup.setRequired(true);
        configGroup.addOption(Option.builder(OPTION_G).argName("template file path").optionalArg(true).hasArg().numberOfArgs(1).valueSeparator().desc("generate template config").build());
        configGroup.addOption(Option.builder(OPTION_C).argName("config file path").optionalArg(false).hasArg().numberOfArgs(1).desc("specify config file").build());
        operationGroup.addOption(Option.builder(OPTION_B).argName("module name to build").optionalArg(true).hasArgs().desc("build all or specified module(s) only").build());
        operationGroup.addOption(Option.builder(OPTION_D).argName("module name to deploy").optionalArg(true).hasArgs().desc("deploy all or specified module(s)").build());
        operationGroup.addOption(Option.builder(OPTION_M).argName("module name to manually deploy").optionalArg(true).hasArgs().desc("deploy all or specified module(s) with existed app(s)").build());
        operationGroup.addOption(Option.builder(OPTION_R).argName("module name to reboot").optionalArg(true).hasArgs().desc("reboot all or specified module(s)").build());
        operationGroup.addOption(Option.builder(OPTION_S).argName("module name to shutdown").optionalArg(true).hasArgs().desc("shutdown all or specified module(s)").build());
        operationGroup.addOption(Option.builder(OPTION_L).argName("module name to fetch log").optionalArg(true).hasArgs().desc("fetch all or specified module's log(s)").build());
        Options options = new Options();
        options.addOptionGroup(configGroup);
        options.addOptionGroup(operationGroup);
        options.addOption(Option.builder(OPTION_P).argName("app's location for manual deployment").optionalArg(false).hasArg().numberOfArgs(1).desc("specify existed app(s)' location for manual deployment").build());
        options.addOption(Option.builder(OPTION_GM).argName("number of module(s) in generated config").optionalArg(false).hasArg().numberOfArgs(1).type(Integer.class).desc("specify number of module(s) in generated template config").build());
        options.addOption(Option.builder(OPTION_GS).argName("number of server(s) for each module in generated config").optionalArg(false).hasArg().numberOfArgs(1).type(Integer.class).desc("specify number of server(s) for each module in generated template config").build());
        CommandLineParser parser = new DefaultParser();
        HelpFormatter helpFormatter = new HelpFormatter();
        try {
            CommandLine cmd = parser.parse(options, args);
            if (cmd.hasOption(OPTION_G)) {
                g(cmd, p(cmd));
            } else if (cmd.hasOption(OPTION_C)) {
                if (cmd.hasOption(OPTION_B)) {
                    b(cmd, c(cmd));
                } else if (cmd.hasOption(OPTION_D)) {
                    d(cmd, c(cmd));
                } else if (cmd.hasOption(OPTION_M)) {
                    if (!cmd.hasOption(OPTION_P)) {
                        throw new MissingOptionException(Collections.singletonList(OPTION_P));
                    }
                    m(cmd, c(cmd));
                } else if (cmd.hasOption(OPTION_R)) {
                    r(cmd, c(cmd));
                } else if (cmd.hasOption(OPTION_S)) {
                    s(cmd, c(cmd));
                } else if (cmd.hasOption(OPTION_L)) {
                    l(cmd, c(cmd));
                } else {
                    throw new MissingOptionException(new ArrayList<>(operationGroup.getNames()));
                }
            }
        } catch (ParseException e) {
            LOGGER.error(e.getLocalizedMessage());
            helpFormatter.printHelp("java -jar ladder", options);
        } catch (MavenInvocationException | IOException | RuntimeException ex) {
            LOGGER.error("Error occurred while executing command.", ex);
        }
    }

    private static void g(CommandLine cmd, Parameter parameter) throws IOException {
        String configPath = cmd.getOptionValue(OPTION_G, DEF_TEMPLATE_FILE_NAME);
        LOGGER.info("Generating config file: {}", configPath);
        Config config = new Config();
        config.basisDir = MANUAL_CONFIG_HINT;
        config.javaHome = Objects.toString(getSysEnvironment(JAVA_HOME_NAME, JRE_HOME_NAME), MANUAL_CONFIG_HINT);
        config.mavenHome = Objects.toString(getSysEnvironment(MAVEN_HOME_NAME, MVN_HOME_NAME, M2_HOME_NAME), MANUAL_CONFIG_HINT);
        config.mavenOpts = JDK_JAVA_OPTIONS_VALUE;
        config.goals = DEF_MAVEN_BUILD_GOALS;
        config.profiles = DEF_MAVEN_PROFILES;
        config.modules = new ArrayList<>(parameter.nom);
        for (int ix = 0, mx = parameter.nom; ix < mx; ix++) {
            Module module = new Module();
            module.name = "ladder" + ix;
            module.order = DEF_ORDER;
            module.artifact = module.name;
            module.servers = new ArrayList<>(parameter.nos);
            List<String> logDirs = Collections.singletonList("/applogs/" + module.name + "/logs");
            for (int kx = 0, sx = parameter.nos; kx < sx; kx++) {
                final long ip = DEF_HOST_IP + kx;
                Server server = new Server();
                server.host = (ip >> 24 & 255) + "." + (ip >> 16 & 255) + "." + (ip >> 8 & 255) + "." + (ip & 255);
                server.username = "app";
                server.password = "app";
                server.uploadDir = "/app/update";
                server.executeDir = "/app/" + module.name;
                server.logDirs = logDirs;
                server.logPatterns = DEF_LOG_EXTENSIONS;
                module.servers.add(server);
            }
            config.modules.add(module);
        }
        Files.write(Paths.get(configPath), OBJECT_MAPPER.writeValueAsBytes(config), OPEN_OPTIONS);
    }

    private static Config c(CommandLine cmd) throws IOException {
        String configPath = cmd.getOptionValue(OPTION_C);
        LOGGER.info("Using config file: {}", configPath);
        return OBJECT_MAPPER.readValue(Files.readAllBytes(Paths.get(configPath)), Config.class);
    }

    private static Parameter p(CommandLine cmd) {
        Parameter parameter = new Parameter();
        parameter.nom = DEF_NUM_OF_MODULE;
        parameter.nos = DEF_NUM_OF_SERVER;
        if (cmd.hasOption(OPTION_GM)) {
            parameter.nom = Math.max(DEF_NUM_OF_MODULE, Math.min(Integer.parseInt(cmd.getOptionValue(OPTION_GM)), MAX_NUM_OF_MODULE));
        }
        if (cmd.hasOption(OPTION_GS)) {
            parameter.nos = Math.max(DEF_NUM_OF_SERVER, Math.min(Integer.parseInt(cmd.getOptionValue(OPTION_GS)), MAX_NUM_OF_SERVER));
        }
        return parameter;
    }

    private static void b(CommandLine cmd, Config config) throws MavenInvocationException {
        Collection<String> specifiedModules = null;
        if (cmd.getOptionValues(OPTION_B) == null || cmd.getOptionValues(OPTION_B).length == 0) {
            LOGGER.info("Building all application(s)");
        } else {
            specifiedModules = new HashSet<>(Arrays.asList(cmd.getOptionValues(OPTION_B)));
            if (LOGGER.isInfoEnabled()) {
                LOGGER.info("Building application(s): {}", Arrays.toString(cmd.getOptionValues(OPTION_B)));
            }
        }
        invoke(config, specifiedModules, (projects, result) -> LOGGER.info("Building module(s) [{}] {}{}", projects,
                result.getExitCode() == 0
                        ? "Successfully." : "Failed.",
                result.getExecutionException() == null
                        ? EMPTY
                        : " Caused by " + result.getExecutionException().getLocalizedMessage()));
    }

    private static void d(CommandLine cmd, Config config) throws MavenInvocationException {
        Collection<String> specifiedModules = null;
        if (cmd.getOptionValues(OPTION_D) == null || cmd.getOptionValues(OPTION_D).length == 0) {
            LOGGER.info("Deploying all application(s)");
        } else {
            specifiedModules = new HashSet<>(Arrays.asList(cmd.getOptionValues(OPTION_D)));
            if (LOGGER.isInfoEnabled()) {
                LOGGER.info("Deploying application(s): {}", Arrays.toString(cmd.getOptionValues(OPTION_D)));
            }
        }
        invoke(config, specifiedModules, ((IOBiConsumer<Collection<String>, InvocationResult>) (projects, result) -> {
            final int code = result.getExitCode();
            if (0 == code) {
                LOGGER.info("Maven build success.");
                Optional<Collection<String>> optionalModules = Optional.ofNullable(projects);
                try (SshClient sshClient = SshClient.setUpDefaultClient()) {
                    sshClient.start();
                    try {
                        config.modules.stream()
                                .filter(module -> !optionalModules.isPresent() || optionalModules.get().contains(module.name))
                                .collect(Collectors.groupingByConcurrent(module -> module.order, ConcurrentSkipListMap::new, Collectors.toList()))
                                .forEach((order, modules) -> modules.parallelStream().forEach(deploy(config.basisDir, true, sshClient)));
                    } finally {
                        sshClient.stop();
                    }
                }
            } else {
                LOGGER.error("Maven build failure.");
            }
        }).asBiConsumer());
    }

    private static void m(CommandLine cmd, Config config) throws IOException {
        String dir = cmd.getOptionValue(OPTION_P);
        Collection<String> specifiedModules = null;
        if (cmd.getOptionValues(OPTION_M) == null || cmd.getOptionValues(OPTION_M).length == 0) {
            LOGGER.info("Deploying all application(s) manually in {}", dir);
        } else {
            specifiedModules = new HashSet<>(Arrays.asList(cmd.getOptionValues(OPTION_M)));
            if (LOGGER.isInfoEnabled()) {
                LOGGER.info("Deploying application(s) manually: {} in {}", Arrays.toString(cmd.getOptionValues(OPTION_M)), dir);
            }
        }
        Optional<Collection<String>> optionalModules = Optional.ofNullable(specifiedModules);
        try (SshClient sshClient = SshClient.setUpDefaultClient()) {
            sshClient.start();
            try {
                config.modules.stream()
                        .filter(module -> !optionalModules.isPresent() || optionalModules.get().contains(module.name))
                        .collect(Collectors.groupingByConcurrent(module -> module.order, ConcurrentSkipListMap::new, Collectors.toList()))
                        .forEach((order, modules) -> modules.parallelStream().forEach(deploy(dir, false, sshClient)));
            } finally {
                sshClient.stop();
            }
        }
    }

    private static void r(CommandLine cmd, Config config) throws IOException {
        Collection<String> specifiedModules = null;
        if (cmd.getOptionValues(OPTION_R) == null || cmd.getOptionValues(OPTION_R).length == 0) {
            LOGGER.info("Rebooting all application(s)");
        } else {
            specifiedModules = new HashSet<>(Arrays.asList(cmd.getOptionValues(OPTION_R)));
            if (LOGGER.isInfoEnabled()) {
                LOGGER.info("Rebooting application(s): {}", Arrays.toString(cmd.getOptionValues(OPTION_R)));
            }
        }
        Optional<Collection<String>> optionalModules = Optional.ofNullable(specifiedModules);
        try (SshClient sshClient = SshClient.setUpDefaultClient()) {
            sshClient.start();
            try {
                config.modules.stream()
                        .filter(module -> !optionalModules.isPresent() || optionalModules.get().contains(module.name))
                        .collect(Collectors.groupingByConcurrent(module -> module.order, ConcurrentSkipListMap::new, Collectors.toList()))
                        .forEach((order, modules) -> modules.parallelStream().forEach(module -> {
                            final String artifact = module.artifact;
                            final String scriptName = artifact + SCRIPT_FILE_EXTENSION;
                            module.servers.parallelStream().forEach(((IOConsumer<Server>) server -> {
                                try (ClientSession session = sshClient.connect(server.username, server.host, server.port).verify().getSession()) {
                                    session.addPasswordIdentity(server.password);
                                    session.auth().verify();
                                    execute(session, String.format("cd %s;sh %s restart", server.executeDir, scriptName));
                                }
                            }).asConsumer());
                        }));
            } finally {
                sshClient.stop();
            }
        }
    }

    private static void s(CommandLine cmd, Config config) throws IOException {
        Collection<String> specifiedModules = null;
        if (cmd.getOptionValues(OPTION_S) == null || cmd.getOptionValues(OPTION_S).length == 0) {
            LOGGER.info("Stopping all application(s)");
        } else {
            specifiedModules = new HashSet<>(Arrays.asList(cmd.getOptionValues(OPTION_S)));
            if (LOGGER.isInfoEnabled()) {
                LOGGER.info("Stopping application(s): {}", Arrays.toString(cmd.getOptionValues(OPTION_S)));
            }
        }
        Optional<Collection<String>> optionalModules = Optional.ofNullable(specifiedModules);
        try (SshClient sshClient = SshClient.setUpDefaultClient()) {
            sshClient.start();
            try {
                config.modules.stream()
                        .filter(module -> !optionalModules.isPresent() || optionalModules.get().contains(module.name))
                        .collect(Collectors.groupingByConcurrent(module -> module.order, ConcurrentSkipListMap::new, Collectors.toList()))
                        .descendingMap()
                        .forEach((order, modules) -> modules.parallelStream().forEach(module -> {
                            final String artifact = module.artifact;
                            final String scriptName = artifact + SCRIPT_FILE_EXTENSION;
                            module.servers.parallelStream().forEach(((IOConsumer<Server>) server -> {
                                try (ClientSession session = sshClient.connect(server.username, server.host, server.port).verify().getSession()) {
                                    session.addPasswordIdentity(server.password);
                                    session.auth().verify();
                                    execute(session, String.format("cd %s;sh %s stop", server.executeDir, scriptName));
                                }
                            }).asConsumer());
                        }));
            } finally {
                sshClient.stop();
            }
        }
    }

    private static void l(CommandLine cmd, Config config) throws IOException {
        Set<String> specifiedModules = null;
        if (cmd.getOptionValues(OPTION_L) == null || cmd.getOptionValues(OPTION_L).length == 0) {
            LOGGER.info("Fetching all application(s)' log(s)");
        } else {
            specifiedModules = new HashSet<>(Arrays.asList(cmd.getOptionValues(OPTION_L)));
            if (LOGGER.isInfoEnabled()) {
                LOGGER.info("Fetching application(s)' log(s): {}", Arrays.toString(cmd.getOptionValues(OPTION_L)));
            }
        }
        Optional<Collection<String>> optionalModules = Optional.ofNullable(specifiedModules);
        String timestamp = Long.toString(System.currentTimeMillis());
        try (SshClient sshClient = SshClient.setUpDefaultClient()) {
            sshClient.start();
            try {
                config.modules.parallelStream()
                        .filter(module -> !optionalModules.isPresent() || optionalModules.get().contains(module.name))
                        .forEach(module -> module.servers.parallelStream().forEach(((IOConsumer<Server>) server -> {
                            final String[] logExtensions = Optional.ofNullable(server.logPatterns).orElse(DEF_LOG_EXTENSIONS);
                            final DirectoryStreamFilter filter = new DirectoryStreamFilter(FileFileFilter.INSTANCE.and(new SuffixFileFilter(logExtensions, IOCase.SYSTEM)));
                            try (ClientSession session = sshClient.connect(server.username, server.host, server.port).verify().getSession()) {
                                session.addPasswordIdentity(server.password);
                                session.auth().verify();
                                server.logDirs.parallelStream().forEach(((IOConsumer<String>) dir -> {
                                    try (SftpFileSystem fileSystem = SftpClientFactory.instance().createSftpFileSystem(session);
                                         DirectoryStream<Path> directoryStream = Files.newDirectoryStream(fileSystem.getPath(dir), filter)) {
                                        final List<CompletableFuture<Void>> futures = new ArrayList<>(logExtensions.length * server.logDirs.size() << 2);
                                        for (Path path : directoryStream) {
                                            futures.add(CompletableFuture.runAsync(((IORunnable) () -> {
                                                Path downloadPath = Paths.get(timestamp, server.host, module.name, path.getFileName().toString());
                                                Files.createDirectories(downloadPath.getParent());
                                                Files.copy(path, downloadPath, StandardCopyOption.REPLACE_EXISTING);
                                                LOGGER.info("File downloaded: [{}] -> [{}]", path.getFileName(), downloadPath);
                                            }).asRunnable()));
                                        }
                                        CompletableFuture.allOf(futures.toArray(EMPTY_COMPLETABLE_FUTURE_ARRAY)).join();
                                    }
                                }).asConsumer());
                            }
                        }).asConsumer()));
            } finally {
                sshClient.stop();
            }
        }
    }

    private static Consumer<Module> deploy(String dir, boolean recursive, SshClient sshClient) {
        return ((IOConsumer<Module>) module -> {
            final String artifact = module.artifact;
            final String jarName = artifact + EXECUTABLE_FILE_EXTENSION;
            final String scriptName = artifact + SCRIPT_FILE_EXTENSION;
            final Predicate<Path> filter = p -> jarName.equals(p.getFileName().toString());
            try (Stream<Path> walker = Files.walk(Paths.get(dir), recursive ? Integer.MAX_VALUE : 1)) {
                walker.filter(filter).findAny().ifPresent(path -> {
                    LOGGER.info("Found: {}", path);
                    module.servers.parallelStream().forEach(((IOConsumer<Server>) server -> {
                        try (ClientSession session = sshClient.connect(server.username, server.host, server.port).verify().getSession()) {
                            session.addPasswordIdentity(server.password);
                            session.auth().verify();
                            try (SftpFileSystem fileSystem = SftpClientFactory.instance().createSftpFileSystem(session)) {
                                Path uploadPath = fileSystem.getPath(server.uploadDir, jarName);
                                Files.createDirectories(uploadPath.getParent());
                                Files.copy(path, uploadPath, StandardCopyOption.REPLACE_EXISTING);
                                LOGGER.info("File uploaded: [{}] -> [{}]", path, uploadPath);
                            }
                            execute(session, String.format("cd %s;sh %s update", server.executeDir, scriptName));
                        }
                    }).asConsumer());
                });
            }
        }).asConsumer();
    }

    private static void invoke(Config config, Collection<String> projects, BiConsumer<Collection<String>, InvocationResult> callback) throws MavenInvocationException {
        InvocationRequest request = new DefaultInvocationRequest();
        request.setShowVersion(true);
        request.setBaseDirectory(new File(config.basisDir));
        request.setJavaHome(Paths.get(config.javaHome).toFile());
        request.addShellEnvironment(JDK_JAVA_OPTIONS_KEY, JDK_JAVA_OPTIONS_VALUE);
        if (config.mavenExecutable != null) {
            request.setMavenExecutable(Paths.get(config.mavenExecutable).toFile());
        }
        request.setMavenHome(Paths.get(config.mavenHome).toFile());
        request.setMavenOpts(config.mavenOpts);
        request.setAlsoMake(true);
        request.setProjects(
                Optional.ofNullable(projects)
                        .map(modules -> modules.stream().map(name -> ':' + name).collect(Collectors.toList()))
                        .orElse(null)
        );
        request.setGoals(Arrays.asList(config.goals));
        request.setProfiles(Optional.ofNullable(config.profiles).map(Arrays::asList).orElse(null));
        request.setInputStream(new NullInputStream());
        request.setOutputHandler(LOGGER::info);
        request.setErrorHandler(LOGGER::error);
        Invoker invoker = new DefaultInvoker();
        invoker.setLogger(new Log(LOGGER));
        callback.accept(projects, invoker.execute(request));
    }

    private static void execute(ClientSession session, String command) throws IOException {
        try (ChannelExec channelExec = session.createExecChannel(command);
             OutputStream outputStream = new LineLevelAppenderStream(new ChannelOutputAppender())) {
            channelExec.setOut(outputStream);
            channelExec.setRedirectErrorStream(true);
            if (channelExec.open().verify(DEF_TIMEOUT).isOpened()) {
                channelExec.waitFor(EnumSet.of(ClientChannelEvent.CLOSED, ClientChannelEvent.EOF), 0L);
            }
        }
    }

    private static String getSysEnvironment(final String name) {
        final String value = System.getProperty(name, System.getenv(name));
        LOGGER.info("Trying to obtain {} from environment: {}", name, StringUtils.isEmpty(value) ? NOT_FOUND_HINT : value);
        return value;
    }

    private static String getSysEnvironment(final String name, final String... candidates) {
        String value = getSysEnvironment(name);
        if (candidates != null && StringUtils.isEmpty(value)) {
            for (String candidate : candidates) {
                value = getSysEnvironment(candidate);
                if (StringUtils.isNotEmpty(value)) {
                    return value;
                }
            }
        }
        return value;
    }

    static class ChannelOutputAppender implements LineLevelAppender {
        private static final Logger LOGGER = LoggerFactory.getLogger(ChannelOutputAppender.class);

        @Override
        public boolean isWriteEnabled() {
            return true;
        }

        @Override
        public void writeLineData(CharSequence lineData) {
            LOGGER.info("{}", lineData);
        }

        @Override
        public void close() {
            LOGGER.info("[{}] closed", this);
        }
    }
}
